<?php
/**
 * STEP class
 *
 * @package             Framework
 */
/**
 * STEP -- Simplest Template Engine Practical
 *
 * Note: By default, untouched child blocks are not output. A block is "touched"
 * when a set variable or non-empty child block is replaced within it or the block
 * is explicitly touched using touchBlock().
 *
 * By default, calling getBlock() for a specific block will parse and return it
 * whether or not it was touched before.
 *
 * When a variable substitution is made during parsing, that variable is "used up"
 * and cleared from the cache.
 *
 * Based on PEAR::HTML_Template_IT  v1.20
 * by   Ulf Wendel <ulf.wendel@phpdoc.de>
 * and Pierre-Alain Joye <pajoye@php.net>
 *
 *
 * EXAMPLE USAGE:
 * 
 * File: report.tpl
 * 
 * <!-- BEGIN report -->
 * <div class="report">
 * 
 *     <h1>Foo Report</h1>
 *     <h4>by {name}</h4>
 *     <p>{name} is {age} years old and sports a {hairstyle} hairstyle.</p>
 * 
 *     <table>
 *         <tr>
 *             <th>Field 1</th>
 *             <th>Field 2</th>
 *         </tr>
 * 
 *         <!-- BEGIN reportRow -->
 *         <tr>
 *             <td>{field_1}</td>
 *             <td>{field_2}</td>
 *         </tr>
 *         <!-- END reportRow -->
 * 
 *         <!-- BEGIN reportNoDataRow -->
 *         <tr>
 *             <td colspan="2">Sorry, no data found.</td>
 *         </tr>
 *         <!-- END reportNoDataRow -->
 * 
 *     </table>
 * </div>
 * <!-- END report -->
 * 
 * 
 * File: foo.php
 * 
 * <?php
 * 
 * $template = new STEP('common/tpl');
 * $template->loadTemplateFile('report.tpl');
 * 
 * $template->set('name',$name);
 * $data = array('age' => 21, 'hairstyle' => 'mullet');
 * $template->set($data);
 * 
 * $report = FooHelper::getReport($bar);
 * if ($report){
 *     foreach ($report as $row) {
 *         $template->set($row);
 * 
 *         $evenOdd = ($evenOdd == 'odd') ? 'even' : 'odd';
 *         $template->set('evenOdd',$evenOdd);
 * 
 *         $name = htmlspecialchars($row['first'].' '.$row['last']);
 *         $template->set('name',$name);
 * 
 *         $template->parse('reportRow'); 
 *     }
 * } else {
 *     $template->touchBlock('reportNoDataRow');
 * }
 * $html = $template->get('report'); 
 * 
 * ?>
 * 
 *
 * PHP Version:         5.1.6
 * MySQL Version:       5.0.22
 *
 * @author              Patrick Fisher <patrick@pwfisher.com>
 * @since               February 12, 2008
 * @package             Framework
 * @subpackage          Template
 *
 */
class STEP {

    /**
     * Array of block name => "raw" block content
     * Contains placeholders for child blocks (e.g. "{__block__}" )
     *
     * @var array
     */
    protected $rawBlocks;

    /**
     * Array of block name => parsed block content
     *
     * @var array
     */
    protected $parsedBlocks;

    /**
     * Block name => array of variables in block
     *
     * @var array
     */
    protected $blockVariables;

    /**
     * Parent block name => array of child block names
     *
     * @var array
     */
    protected $childBlocks;

    /**
     * Block name => true/false
     *
     * @var array
     */
    protected $touchedBlocks;

    /**
     * Array of template variable name => template variable value
     *
     * @var array
     */
    protected $variableCache;

    /**
     * Path to template file
     *
     * @var string
     */
    protected $pathToTemplate;

    /**
     * The template file name
     *
     * @var string
     */
    protected $templateFile;

    /**
     * CONSTRUCTOR
     */
    public function __construct($pathToTemplate = '') {

        $this->reset();
        $this->pathToTemplate           = $pathToTemplate;
    }


    /**
     * RESET
     *
     * Initializes arrays and sets default values.
     */
    protected function reset() {
        $this->rawBlocks                = array();
        $this->parsedBlocks             = array();
        $this->blockVariables           = array();
        $this->childBlocks              = array();
        $this->touchedBlocks            = array();
        $this->variableCache            = array();
    }


    /**
     * Reads a template file from the disk and processes the template contents
     *
     * @param string $filename Path and filename of the template file
     * @return boolean True on success, false on failure
     */
    public function loadTemplateFile($fileName) {

        $this->reset();

        // Prepend root directory to filename
        if (!empty($this->pathToTemplate)) {
            $fileName               = $this->pathToTemplate.'/'.$fileName;
        }

        // Read the file from disk
        if (file_exists($fileName)){
            $fileContents           = file_get_contents($fileName);
        } else {
            throw new Exception("The template <i>$fileName</i> does not exist");
        }

        // If we couldn't read the file, throw an error
        if ($fileContents === false) {
            throw new Exception('Could not read file: '.$fileName);
        }

        $this->templateFile         = $fileName;
        $this->processTemplateContents($fileContents);
    }

    /**
     * Reads a string of HTML and processes it as template contents
     *
     * @param string $templateHTML String of template HTML
     */
    public function loadTemplateHTML($templateHTML){
        $this->reset();
        $this->processTemplateContents($templateHTML);
    }

    /**
     * Processes template contents to create internal data structures
     *
     * @param string $templateContents Template html
     */
    protected function processTemplateContents($templateContents){

        // Create the "__root__" block to contain it all
        $templateContents               = '<!-- BEGIN __root__ -->'.$templateContents.'<!-- END __root__ -->';

        $templateContents               = $this->loadIncludes($templateContents);

        $this->findBlocks($templateContents);
        $this->findVariables();
    }

    /**
     * Replaces <!-- INCLUDE fileName --> with contents of fileName (which may have includes,
     * which may have includes, etc. etc.)
     *
     * @param $haystack The template to replace <!-- INCLUDE fileName --> within
     */
    protected function loadIncludes($haystack) {

        // Find INCLUDEs in search string
        if (preg_match_all('/<!--\s+INCLUDE\s+(.*?)\s+-->/s', $haystack, $result, PREG_SET_ORDER)) {

            foreach ($result as $match) {

                $includePlaceholder             = $match[0];
                $includeFileName                = $match[1];

                // Prepend server path to filename, if it's not already included
                if (strpos($includeFileName,SERVER_APP) === false) {
                    $includeFileName            = SERVER_APP.'/'.ltrim_str($includeFileName,'/');
                }

                // Read the include file
                if(file_exists($includeFileName)) {
                    $fileContents               = file_get_contents($includeFileName);
                } else {
                    throw new Exception('Could not find template include file at '.$includeFileName);
                }

                // Recusively load any includes within the included file
                $fileContents                   = $this->loadIncludes($fileContents);

                // And replace the include placeholder with contents
                $haystack                       = str_replace($includePlaceholder,$fileContents,$haystack);
            }
        }
        return $haystack;
    }

    /**
     * Recursively builds block data structures.
     *
     * Returns a list of all blocks in $haystack.
     * The return value is used by recursive calls to replace child blocks with placeholders.
     */
    protected function findBlocks($haystack) {

        // Blocks look like: <!-- BEGIN myBlock -->Blah Blah {variable} blah blah<!-- END myBlock -->
        $blockPattern = '/<!--\s+BEGIN\s+([\.0-9A-Za-z|_-]+)\s+-->(.*)<!--\s+END\s+\1\s+-->/s';
        $foundBlocks                                    = array();

        // Find blocks in search string
        if (preg_match_all($blockPattern, $haystack, $result, PREG_SET_ORDER)) {

            // For each block found
            foreach ($result as $match) {

                $blockName                              = $match[1];
                $blockContent                           = $match[2];

                // Add block name to array of found blocks (to be replaced with placeholders)
                $foundBlocks[]                          = $this->addBlock($blockName,$blockContent);;

                // Recursively find child blocks
                $childBlocks                            = $this->findBlocks($blockContent);

                foreach ($childBlocks as $childBlockName) {
                    $this->addChildBlock($blockName,$childBlockName);
                }
            }
        }
        return $foundBlocks;
    }

    /**
     * Add found block to template data structure
     *
     * @param string $blockName The name of the found block
     * @param string $blockContent The content of the found block
     */
    protected function addBlock($blockName,$blockContent){

        if (isset($this->rawBlocks[$blockName])) {
            throw new Exception('Duplicate block name '.$blockName.' in '.$this->templateFile);
        }

        $this->rawBlocks[$blockName]            = $blockContent;
        $this->parsedBlocks[$blockName]         = '';
        $this->touchedBlocks[$blockName]        = false;
        $this->childBlocks[$blockName]          = array();
        return $blockName;
    }

    /**
     * Add child block to parent/child data structure
     *
     * @param string $parentBlockName The name of the parent block
     * @param string $childBlockName The name of the child block
     */
    protected function addChildBlock($parentBlockName,$childBlockName){

        $escapedChildBlockName                  = str_replace('.','\.',$childBlockName);
        $temp                                   = $escapedChildBlockName;
        $childBlockPattern = '/<!--\s+BEGIN\s+'.$temp.'\s+-->(.*)<!--\s+END\s+'.$temp.'\s+-->/s';

        // Replace child block with placeholder
        $this->rawBlocks[$parentBlockName]      = preg_replace($childBlockPattern, '{__'.$childBlockName.'__}',$this->rawBlocks[$parentBlockName]);

        // Add this relationship to parent/child data structure
        $this->childBlocks[$parentBlockName][]  = $childBlockName;
    }


    /**
     * Builds block variable lists.
     */
    protected function findVariables() {

        foreach ($this->rawBlocks as $blockName => $blockContent) {

            // Find all variables (e.g. "{variableName}")
            // The lookahead assertion excludes block placeholders (e.g. "{__blockName__}")
            preg_match_all('/(?!\{__[\.0-9A-Za-z|_-]+__\})\{([\.0-9A-Za-z|_-]+)\}/', $blockContent, $variableNames);

            // Store found variable names
            if (count($variableNames[1]) > 0) {
                foreach ($variableNames[1] as $variableName) {
                    $this->blockVariables[$blockName][] = $variableName;
                }
            }
        }
    }

    /**
     * Parses the given block, replacing variables and child blocks.
     *
     * The result is APPENDED to $this->parsedBlocks[$blockName].  Thus, a block
     * may be repeated by calling parseBlock() multiple times.
     *
     * @param string $blockName The name of the block to parse
     */
    public function parse($blockName = '__root__') {

        $parsedVariables                = $this->parseBlock($blockName);

        // Clear all "used" variables from the cache
        foreach ($parsedVariables as $variableName) {
            unset($this->variableCache[$variableName]);
        }
        $this->touchBlock($blockName);
    }

    /**
     * Parses block, replacing variables and child blocks with rendered content
     *
     * @param string $blockName The name of the block to parse
     * @param array Array of variables parsed from block
     * @return array Array of variables "used" during parsing
     */
    private function parseBlock($blockName = '__root__', $parsedVariables = null) {

        $this->checkBlockExists($blockName);

        // The first time this function is called, initialize array of parsed variables
        if (is_null($parsedVariables)) {
            $parsedVariables                = array();
        }
        // On recursive calls, if the requested block has already been parsed, we're done
        else if (!empty($this->parsedBlocks[$blockName])) {
            return $parsedVariables;
        }

        // Grab the raw block
        $workingCopy                        = $this->rawBlocks[$blockName];

        // Parse each variable and add to working copy
        if (array_key_exists($blockName,$this->blockVariables)) {

            foreach ($this->blockVariables[$blockName] as $variableName) {
                $parsedVariables[]          = $variableName;
                $workingCopy                = $this->parseVariable($variableName,$blockName,$workingCopy);
            }
        }

        // Parse the child blocks and add to working copy
        foreach ($this->childBlocks[$blockName] as $childBlockName) {

            // Recursively parse the contents of the child block
            $parsedVariables                        = $this->parseBlock($childBlockName, $parsedVariables);
            $parsedChildBlock                       = $this->parsedBlocks[$childBlockName];
            $this->parsedBlocks[$childBlockName]    = '';
            $this->untouchBlock($childBlockName);

            // If the child block is not empty, flag block as touched
            if (!empty($parsedChildBlock)) {
                $this->touchBlock($blockName);
            }

            // Replace child block placeholder with parsed contents
            $workingCopy                    = $this->replaceVariable('__'.$childBlockName.'__',$parsedChildBlock,$workingCopy);
        }

        // If the block has been touched, use the working copy
        $workingCopy                       = ($this->isTouched($blockName)) ? $workingCopy : '';

        // Append the parsed block contents to the output cache
        $this->parsedBlocks[$blockName]    .= $workingCopy;

        return $parsedVariables;
    }

    /**
     * Replaces variable placeholder in working copy, and flags block as touched
     *
     * @param string $variableName The name of the variable to parse
     * @param string $blockName The name of the block in which the variable is found
     * @param string $workingCopy The in-progress rendering of the block being parsed
     * @return string The working copy with the variable replaced
     */
    private function parseVariable($variableName,$blockName,$workingCopy){

        // If the variable has a value set, get the value and flag the block as touched
        if (isset($this->variableCache[$variableName])) {

            $variableValue                  = $this->variableCache[$variableName];
            $this->touchBlock($blockName);

        // Otherwise, just remove the marker for the unset variable
        } else {
            $variableValue                  = '';
        }
        return $this->replaceVariable($variableName,$variableValue,$workingCopy);
    }

    /**
     * Alias for getBlock()
     */
    public function get($blockName = '__root__', $touchRequestedBlock = true) {
        return $this->getBlock($blockName, $touchRequestedBlock);
    }

    /**
     * Returns the parsed contents of a block.
     *
     * If the block has been parsed multiple times (presumably with different
     * data), this will return all the parsed output.
     *
     * @param string $blockName The name of the block to get
     * @param bool $touchRequestedBlock Touch the requested block before parsing it
     * @return string Parsed block contents
     * @see parseBlock()
     */
    public function getBlock($blockName = '__root__', $touchRequestedBlock = true) {

        $this->checkBlockExists($blockName);

        if ($touchRequestedBlock == true) {
            $this->touchBlock($blockName);
        }

        // If the requested block has not already been parsed, do it now
        if (empty($this->parsedBlocks[$blockName])) {
            $this->parseBlock($blockName);
        }

        // Return the block's parsed output
        $return                             = $this->parsedBlocks[$blockName];

        // Clear the output buffer and untouch the block
        $this->parsedBlocks[$blockName]     = '';
        $this->untouchBlock($blockName);

        return $return;
    }

    /**
     * Adds a name=>value pair to the variable cache
     *
     * @param mixed $input Assoc array of name=>value pairs, object with public properties, or
     *                      name of single variable
     * @param mixed $value If $input is a variable name, $value is the variable value
     */
    public function set($input, $value = null) {

        // Value must be scalar and not an empty string
        $isValidKeyValuePair            = (is_string($input) && ((is_scalar($value) && !(is_string($value) && strlen($value) == 0))));

        if ($isValidKeyValuePair === true){
            $this->variableCache[$input] = $value;
        } else if (is_array($input) || is_object($input)){
            $this->setVariables($input);
        }
    }

    /**
     * Sets variables using associative array of name=>value pairs,
     * or an object with public properties
     *
     * NOTE: This only works for arrays or objects whose elements/properties contain
     * scalar values. If the value is an array, object, or other nonscalar, it will be skipped.
     *
     * @param array|object $nonScalar Associative array or object
     */
    public function setVariables($nonScalar){

        if (empty($nonScalar)) return;

        foreach ($nonScalar as $key => $value){

            // Value must be scalar and not an empty string
            $isValidKeyValuePair            = (is_string($key) && ((is_scalar($value) && !(is_string($value) && strlen($value) == 0))));

            if ($isValidKeyValuePair === true){
                $this->set($key,$value);
            }
        }
    }

    /**
     * Substitutes value for variable placeholder
     *
     * @param string $name The name of the variable to replace
     * @param mixed $value The value to replace the variable placeholder with
     * @param string $haystack The block of content in which to replace the variable
     * @return string Content in which variable placeholder is replaced with value
     */
    protected function replaceVariable($name, $value, $haystack) {
        return str_replace('{'.$name.'}', $value, $haystack);
    }
    
    /**
     * Alias for touchBlock()
     */
    public function touch($blockName) {
        return $this->touchBlock($blockName);
    }

    /**
     * Flags a block as "touched" so that its contents will be output even if
     * no replacements are made within it.
     *
     * @param string $blockName The name of the block to touch
     */
    public function touchBlock($blockName) {
        $this->checkBlockExists($blockName);

        // Flag the block as touched
        $this->touchedBlocks[$blockName] = true;
    }

    /**
     * Resets the "touched" flag for the given block and all its descendents.
     *
     * @param string $blockName The name of the block to untouch
     */
    public function untouchBlock($blockName) {
        $this->checkBlockExists($blockName);

        // Flag the block as untouched
        $this->touchedBlocks[$blockName] = false;

        // Untouch all child blocks (recursive)
        foreach ($this->childBlocks[$blockName] as $childBlockName) {
            $this->untouchBlock($childBlockName);
        }
    }

    /**
     * True if the selected block has been touched
     *
     * @param string $blockName The name of the block to check
     * @return boolean
     */
    public function isTouched($blockName) {
        $this->checkBlockExists($blockName);

        return $this->touchedBlocks[$blockName];
    }

    /**
     * Checks whether the requested block exists
     *
     * @param string $blockName The name of the block to check
     * @return boolean
     */
    protected function checkBlockExists($blockName) {
        if (!array_key_exists($blockName,$this->rawBlocks)) {
            throw new Exception("Requested block $blockName not found in {$this->templateFile}");
        }
        return true;
    }

    /**
     * Check that the template has a specific block
     *
     * @param string $blockName The name of the block to check
     * @return boolean True if the block exists in the template
     */
    public function hasBlock($blockName){
        return array_key_exists($blockName,$this->rawBlocks);
    }
}
?>
